summaryrefslogtreecommitdiff
path: root/app/api/auth/[...nextauth]/saml
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/auth/[...nextauth]/saml')
-rw-r--r--app/api/auth/[...nextauth]/saml/provider.ts128
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts405
2 files changed, 533 insertions, 0 deletions
diff --git a/app/api/auth/[...nextauth]/saml/provider.ts b/app/api/auth/[...nextauth]/saml/provider.ts
new file mode 100644
index 00000000..92099be0
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/provider.ts
@@ -0,0 +1,128 @@
+import CredentialsProvider from "next-auth/providers/credentials"
+import { getOrCreateSAMLUser, validateSAMLUserData } from '@/lib/users/saml-service'
+
+interface SAMLProviderOptions {
+ id: string
+ name: string
+ idp: {
+ sso_login_url: string
+ sso_logout_url: string
+ certificates: string[]
+ }
+ sp: {
+ entity_id: string
+ private_key: string
+ certificate: string
+ assert_endpoint: string
+ }
+}
+
+export function SAMLProvider(options: SAMLProviderOptions) {
+ return CredentialsProvider({
+ id: options.id,
+ name: options.name,
+ credentials: {
+ user: {
+ label: "User Data",
+ type: "text"
+ }
+ },
+ async authorize(credentials) {
+ try {
+ if (!credentials?.user) {
+ console.error('No user data provided')
+ return null
+ }
+
+ console.log('πŸ” SAML Provider: Processing user data')
+
+ // μ‚¬μš©μž 데이터 νŒŒμ‹± (UTF-8 처리 κ°œμ„ )
+ const userDataString = credentials.user
+ console.log('πŸ”€ Raw user data string:', userDataString.substring(0, 200) + '...')
+
+ const userData = JSON.parse(userDataString)
+
+ // νŒŒμ‹±λœ λ°μ΄ν„°μ˜ UTF-8 확인
+ console.log('πŸ”€ Parsed user data UTF-8 check:', {
+ name: userData.name,
+ nameLength: userData.name?.length,
+ charCodes: userData.name ? [...userData.name].map(c => c.charCodeAt(0)) : []
+ })
+
+ if (!userData.id || !userData.email) {
+ console.error('Invalid SAML user data:', userData)
+ return null
+ }
+
+ console.log('βœ… SAML Provider: User authenticated successfully', {
+ id: userData.id,
+ email: userData.email,
+ name: userData.name
+ })
+
+ // πŸ”₯ SAML μ‚¬μš©μž 데이터 검증
+ const isValidData = await validateSAMLUserData(userData)
+ if (!isValidData) {
+ console.error('Invalid SAML user data structure:', userData)
+ return null
+ }
+
+ // πŸ”₯ JIT (Just-In-Time) μ‚¬μš©μž 생성 λ˜λŠ” 쑰회
+ const dbUser = await getOrCreateSAMLUser({
+ email: userData.email,
+ name: userData.name,
+ // companyId: userData.companyId,
+ // techCompanyId: userData.techCompanyId,
+ // ! domain = evcp 이면 vendorκ°€ κ°–λŠ” companyId, techCompanyIdλŠ” null
+ companyId: undefined,
+ techCompanyId: undefined,
+ domain: userData.domain
+ })
+
+ if (!dbUser) {
+ console.error('Failed to get or create SAML user')
+ return null
+ }
+
+ // DBμ—μ„œ κ°€μ Έμ˜¨ μ‹€μ œ μ‚¬μš©μž 정보 λ°˜ν™˜
+ const userResult = {
+ id: String(dbUser.id), // DB의 μ‹€μ œ ID
+ name: dbUser.name, // DB의 μ‹€μ œ 이름
+ email: dbUser.email, // DB의 μ‹€μ œ 이메일
+ companyId: dbUser.companyId, // DB의 μ‹€μ œ νšŒμ‚¬ ID
+ techCompanyId: dbUser.techCompanyId, // DB의 μ‹€μ œ κΈ°μˆ νšŒμ‚¬ ID
+ domain: dbUser.domain, // DB의 μ‹€μ œ 도메인
+ imageUrl: dbUser.imageUrl, // DB의 μ‹€μ œ 이미지 URL
+ }
+
+ console.log('βœ… SAML Provider: Returning user data to NextAuth:', userResult)
+ return userResult
+ } catch (error) {
+ console.error('❌ SAML Provider: Authentication failed', error)
+ return null
+ }
+ }
+ })
+}
+
+// SAML 둜그인 URL 생성 헬퍼 ν•¨μˆ˜
+export function getSAMLLoginUrl(options: SAMLProviderOptions): string {
+ const params = new URLSearchParams({
+ SAMLRequest: 'placeholder', // μ‹€μ œλ‘œλŠ” createAuthnRequest()둜 생성
+ RelayState: options.sp.assert_endpoint,
+ })
+
+ return `${options.idp.sso_login_url}?${params.toString()}`
+}
+
+// SAML μ„€μ • 검증
+export function validateSAMLOptions(options: SAMLProviderOptions): boolean {
+ const required = [
+ options.idp.sso_login_url,
+ options.sp.entity_id,
+ options.sp.assert_endpoint
+ ]
+
+ return required.every(field => field && field.length > 0)
+}
+ \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts
new file mode 100644
index 00000000..7dfe9581
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -0,0 +1,405 @@
+import { SAML, ValidateInResponseTo } from "@node-saml/node-saml";
+import {
+ getIDPMetadata,
+ normalizeCertificate,
+} from "@/lib/saml/idp-metadata";
+import {
+ getSPMetadata,
+} from "@/lib/saml/sp-metadata";
+
+export interface SAMLProfile {
+ nameID?: string;
+ nameIDFormat?: string;
+ attributes?: Record<string, string[]>;
+ [key: string]: unknown;
+}
+
+export interface SAMLUser {
+ id: string;
+ email: string;
+ name: string;
+ companyId?: number;
+ techCompanyId?: number;
+ domain?: string;
+}
+
+// SAML μ„€μ • 생성 (sync ν•¨μˆ˜) - ν™˜κ²½λ³€μˆ˜ 기반으둜 λ³€κ²½ν–ˆμŒ
+export function createSAMLConfig() {
+ console.log("βš™οΈ Creating SAML configuration...");
+
+ try {
+ const idpMetadata = getIDPMetadata();
+ const spMetadata = getSPMetadata();
+
+ console.log("πŸ“‹ IdP Metadata loaded:", {
+ entityId: idpMetadata.entityId,
+ ssoUrl: idpMetadata.ssoUrl,
+ organization: idpMetadata.organization,
+ wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned,
+ });
+
+ console.log("πŸ“‹ SP Metadata loaded:", {
+ entityId: spMetadata.entityId,
+ callbackUrl: spMetadata.callbackUrl,
+ authnRequestsSigned: spMetadata.authnRequestsSigned,
+ });
+
+ const config = {
+ callbackUrl: spMetadata.callbackUrl,
+ // IDP 메타데이터 기반 μ„€μ •
+ entryPoint: idpMetadata.ssoUrl,
+ // SP Entity ID
+ issuer: spMetadata.entityId,
+ // IDP μΈμ¦μ„œ (μ •κ·œν™”λœ PEM ν˜•μ‹)
+ idpCert: normalizeCertificate(idpMetadata.certificate),
+ privateKey: process.env.SAML_SP_PRIVATE_KEY,
+ // IdPμ—μ„œ μš”κ΅¬ν•˜λŠ” μ„€μ •
+ identifierFormat: idpMetadata.nameIdFormat,
+ signatureAlgorithm: "sha256" as const,
+ digestAlgorithm: "sha256",
+ // SP 메타데이터 μ„€μ •
+ decryptionPvk: process.env.SAML_SP_PRIVATE_KEY,
+ publicCert: process.env.SAML_SP_CERT,
+ // IdP 메타데이터 기반 μ„€μ •
+ wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned,
+ wantAssertionsSigned: spMetadata.wantAssertionsSigned,
+ validateInResponseTo: ValidateInResponseTo.never,
+ disableRequestedAuthnContext: true,
+ // HTTP-Redirect 바인딩 μ„€μ •
+ authnRequestBinding: undefined, // HTTP-Redirect (GET) μ‚¬μš© (κΈ°λ³Έκ°’)
+ skipRequestCompression: false, // Deflate μ••μΆ• μ‚¬μš©
+ // μΆ”κ°€ λ³΄μ•ˆ μ„€μ •
+ acceptedClockSkewMs: 5000, // 5초 클럭 차이 ν—ˆμš©
+ forceAuthn: false,
+ // IDP Entity ID μ„€μ •
+ idpIssuer: idpMetadata.entityId,
+ };
+
+ console.log("βœ… SAML Config created:", {
+ callbackUrl: config.callbackUrl,
+ entryPoint: config.entryPoint,
+ issuer: config.issuer,
+ idpIssuer: config.idpIssuer,
+ identifierFormat: config.identifierFormat,
+ hasIdpCert: !!config.idpCert,
+ hasPrivateKey: !!config.privateKey,
+ hasPublicCert: !!config.publicCert,
+ wantAuthnResponseSigned: config.wantAuthnResponseSigned,
+ wantAssertionsSigned: config.wantAssertionsSigned,
+ });
+
+ return config;
+ } catch (error) {
+ console.error("πŸ’₯ Failed to create SAML Config:", error);
+ throw error;
+ }
+}
+
+// SAML AuthnRequest 생성 (μ„œλ²„ μ•‘μ…˜)
+export async function createAuthnRequest(): Promise<string> {
+ "use server";
+
+ console.log("SSO STEP 2: Create AuthnRequest");
+
+ try {
+ const config = createSAMLConfig();
+ console.log("SAML Config ready for AuthnRequest generation");
+
+ const saml = new SAML(config);
+ console.log("SAML instance created, generating authorize URL...");
+
+ const startTime = Date.now();
+ const authorizeUrl = await saml.getAuthorizeUrlAsync(
+ "", // RelayState
+ undefined, // host
+ {
+ additionalParams: {},
+ // additionalAuthorizeParams: {},
+ }
+ );
+ const endTime = Date.now();
+
+ // πŸ” SAML AuthnRequest λ””μ½”λ”© 및 뢄석
+ try {
+ const urlObj = new URL(authorizeUrl);
+ const samlRequest = urlObj.searchParams.get("SAMLRequest");
+
+ if (samlRequest) {
+ console.log("SAML AuthnRequest 뢄석:");
+ console.log("1️⃣ 원본 URL:", authorizeUrl);
+ console.log(
+ "2️⃣ URL λ””μ½”λ”©λœ SAMLRequest:",
+ decodeURIComponent(samlRequest)
+ );
+
+ try {
+ // Base64 λ””μ½”λ”©
+ const base64DecodedBuffer = Buffer.from(
+ decodeURIComponent(samlRequest),
+ "base64"
+ );
+ const base64DecodedString = base64DecodedBuffer.toString("utf-8");
+
+ // XML인지 확인 (XML은 '<'둜 μ‹œμž‘ν•¨)
+ if (base64DecodedString.trim().startsWith("<")) {
+ console.log("Base64 λ””μ½”λ”©λœ XML (μ••μΆ• μ—†μŒ):");
+ console.log("───────────────────────────────────");
+ console.log(base64DecodedString);
+ console.log("───────────────────────────────────");
+
+ // XML ꡬ쑰 뢄석
+ const xmlLines = base64DecodedString
+ .split("\n")
+ .filter((line) => line.trim());
+ console.log("XML ꡬ쑰 μš”μ•½:");
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+ } else {
+ // XML이 μ•„λ‹ˆλ©΄ Deflate μ••μΆ•λœ κ²ƒμœΌλ‘œ κ°„μ£Ό
+ console.log(
+ "3️⃣ μ••μΆ•λœ λ°”μ΄λ„ˆλ¦¬ 데이터 감지, Deflate μ••μΆ• ν•΄μ œ μ‹œλ„..."
+ );
+
+ try {
+ const zlib = require("zlib");
+ const decompressed = zlib
+ .inflateRawSync(base64DecodedBuffer)
+ .toString("utf-8");
+ console.log("Deflate μ••μΆ• ν•΄μ œλœ XML:");
+ console.log("───────────────────────────────────");
+ console.log(decompressed);
+ console.log("───────────────────────────────────");
+
+ // XML ꡬ쑰 뢄석
+ const xmlLines = decompressed
+ .split("\n")
+ .filter((line) => line.trim());
+ console.log("XML ꡬ쑰 μš”μ•½:");
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=") ||
+ trimmed.includes("Issuer>") ||
+ trimmed.includes("AssertionConsumerServiceURL=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+
+ // μ€‘μš”ν•œ 정보 μΆ”μΆœ
+ const idMatch = decompressed.match(/ID="([^"]+)"/);
+ const destinationMatch = decompressed.match(
+ /Destination="([^"]+)"/
+ );
+ const issuerMatch = decompressed.match(
+ /<saml:Issuer[^>]*>([^<]+)<\/saml:Issuer>/
+ );
+ const acsMatch = decompressed.match(
+ /AssertionConsumerServiceURL="([^"]+)"/
+ );
+
+ console.log("μΆ”μΆœλœ 핡심 정보:");
+ console.log(` Request ID: ${idMatch ? idMatch[1] : "μ—†μŒ"}`);
+ console.log(
+ ` Destination: ${
+ destinationMatch ? destinationMatch[1] : "μ—†μŒ"
+ }`
+ );
+ console.log(
+ ` Issuer: ${issuerMatch ? issuerMatch[1] : "μ—†μŒ"}`
+ );
+ console.log(
+ ` Callback URL: ${acsMatch ? acsMatch[1] : "μ—†μŒ"}`
+ );
+ } catch (inflateError) {
+ console.log("❌ Deflate μ••μΆ• ν•΄μ œ μ‹€νŒ¨:", inflateError.message);
+ console.log(
+ " 원본 λ°”μ΄λ„ˆλ¦¬ 데이터 (hex):",
+ base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
+ );
+ }
+ }
+ } catch (decodeError) {
+ console.log("❌ Base64 λ””μ½”λ”© μ‹€νŒ¨:", decodeError.message);
+ }
+ }
+ } catch (analysisError) {
+ console.log("⚠️ SAML AuthnRequest 뢄석 쀑 였λ₯˜:", analysisError.message);
+ }
+
+ console.log("βœ… SAML AuthnRequest URL generated:", {
+ url: authorizeUrl.substring(0, 100) + "...",
+ fullUrlLength: authorizeUrl.length,
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return authorizeUrl;
+ } catch (error) {
+ console.error("πŸ’₯ Failed to create SAML AuthnRequest:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ timestamp: new Date().toISOString(),
+ });
+ throw error;
+ }
+}
+
+// SAML Response 검증 및 νŒŒμ‹± (μ„œλ²„ μ•‘μ…˜)
+export async function validateSAMLResponse(
+ samlResponse: string
+): Promise<SAMLProfile> {
+ "use server";
+
+ console.log("πŸ” Starting SAML Response validation...");
+ console.log("πŸ“Š SAML Response info:", {
+ responseLength: samlResponse.length,
+ firstChars: samlResponse.substring(0, 50) + "...",
+ isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse),
+ timestamp: new Date().toISOString(),
+ });
+
+ // μ‹€μ œ SAML 검증 μˆ˜ν–‰ (κΈ°λ³Έκ°’)
+ console.log(
+ "πŸ” Using Real SAML validation (SAML_USE_MOCKUP=false or not set)"
+ );
+
+ try {
+ console.log("βš™οΈ Creating SAML instance for validation...");
+ const saml = new SAML(createSAMLConfig());
+ console.log("βœ… SAML instance created, starting validation...");
+
+ const startTime = Date.now();
+ const result = await saml.validatePostResponseAsync({
+ SAMLResponse: samlResponse,
+ });
+ const endTime = Date.now();
+
+ // node-saml λΌμ΄λΈŒλŸ¬λ¦¬λŠ” { profile, loggedOut } ν˜•νƒœλ‘œ λ°˜ν™˜
+ const profile = result.profile;
+ if (!profile) {
+ throw new Error("No profile returned from SAML validation");
+ }
+
+ // SAMLProfile ν˜•νƒœλ‘œ λ³€ν™˜
+ const samlProfile: SAMLProfile = {
+ nameID: profile.nameID,
+ nameIDFormat: profile.nameIDFormat,
+ attributes: profile.attributes || {},
+ };
+
+ console.log("βœ… Real SAML Profile validated successfully:", {
+ nameID: samlProfile.nameID,
+ nameIDFormat: samlProfile.nameIDFormat,
+ attributeCount: Object.keys(samlProfile.attributes || {}).length,
+ attributes: Object.keys(samlProfile.attributes || {}),
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return samlProfile;
+ } catch (error) {
+ console.error("❌ Real SAML validation error:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ samlResponseLength: samlResponse.length,
+ timestamp: new Date().toISOString(),
+ });
+ throw new Error(
+ `SAML validation failed: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`
+ );
+ }
+}
+
+// SAML Profile을 User 객체둜 λ³€ν™˜ (sync ν•¨μˆ˜)
+export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser {
+ console.log("πŸ”„ Mapping SAML profile to user:", {
+ nameID: profile.nameID,
+ attributes: profile.attributes,
+ });
+
+ // 기본적으둜 nameIDλ₯Ό μ‚¬μš©ν•˜κ±°λ‚˜ attributesμ—μ„œ μΆ”μΆœ
+ const id =
+ profile.nameID ||
+ profile.attributes?.uid?.[0] ||
+ profile.attributes?.employeeNumber?.[0] ||
+ "";
+ const email =
+ profile.attributes?.email?.[0] ||
+ profile.attributes?.mail?.[0] ||
+ profile.nameID ||
+ "";
+ // UTF-8 이름 처리 κ°œμ„ 
+ let name =
+ profile.attributes?.displayName?.[0] ||
+ profile.attributes?.cn?.[0] ||
+ profile.attributes?.name?.[0] ||
+ (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0]
+ ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0]
+ : "") ||
+ "";
+
+ // UTF-8 λ¬Έμžμ—΄ μ •κ·œν™” 및 검증
+ if (name && typeof name === "string") {
+ name = name.normalize("NFC").trim();
+
+ // ν•œκΈ€μ΄ κΉ¨μ§„ 경우 감지 및 둜그
+ const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name);
+ if (hasInvalidChars) {
+ console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
+ originalName: name,
+ charCodes: [...name].map((c) => c.charCodeAt(0)),
+ hexDump: [...name]
+ .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join(""),
+ });
+ }
+ }
+
+ // νšŒμ‚¬ μ •λ³΄λŠ” SSO 둜그인 μ‹œ μ—†μŒ
+ const companyId = undefined;
+ const techCompanyId = undefined;
+ const domain = 'evcp';
+
+ const user = {
+ id,
+ email,
+ name: name.trim(),
+ companyId,
+ techCompanyId,
+ domain,
+ };
+
+ console.log("πŸ‘€ Mapped user object:", user);
+
+ return user;
+}
+
+// SAML λ‘œκ·Έμ•„μ›ƒ URL 생성 (μ„œλ²„ μ•‘μ…˜)
+// λ‘œκ·Έμ•„μ›ƒ 지원 μ•ˆν•¨. 일단 ꡬ쑰만 μœ μ‚¬ν•˜κ²Œ μž‘μ„±ν•΄λ‘ .
+export async function createLogoutRequest(nameID: string): Promise<string> {
+ "use server";
+
+ const saml = new SAML(createSAMLConfig());
+ return await saml.getLogoutUrlAsync(
+ nameID,
+ "", // RelayState
+ {
+ nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ }
+ );
+}